Howard之前有在工作室分享爬蟲的議題,並舉出這個有趣的例子。當時,就有說處理的流程及大概的做法,筆者就試著將這例子給實作出來,並分享給大家。
這次的目標是藉由7–11電子地圖網站來抓取的資料則是各縣市地區的店家資料。如:
門市店號:141659
門市店名:石光地址:新竹縣關西鎮石光里石岡子252號1樓
電話:(03)5868624
接收傳真服務(付費):(03)5868654
在處理這次的爬蟲前,我們要先釐清我們所要抓取的資料,它是夾雜在前端render
還是後端render
。
那要如何進行辨識呢?很簡單,我們可以藉由瀏覽器所提供的開發者工具
來進行辨識。以Chrome為例,先開啟開發者工具。開啟方式為:windows鍵盤的朋友(ctrl + shift + i)、Mac鍵盤的朋友(commend + option + i)。
開啟之後,點選Network,在從 XHR(XMLHttpRequest)、Doc或JS中去尋找些線索。
在這次的目標中,我們先進入到目標資料的頁面。如下圖:
在目標頁面開啟開發者工具,待搜索後,可以看到我們所要觀察的目標就在XHR的類別底下,有個Name為EMapSDK.aspx的物件,點進去後在透過Preview來觀看可以發現我們要的資料就在該物件底下。如下圖所示:
註記:Preview會根據該物件所回傳的資料類型(JSON、文本、圖片…等。)方式來呈現其相對應的預覽。
這時,我們就能發現它所回傳來的資料算是XML的格式。以此就能推斷這個目標格式就夾雜在「前端render」的API裡。
接著,我們就在回到開發者工具,在從Headers裡面來看整個HTTP的訊息。能發現目標資源的URL為http://emap.pcsc.com.tw/EMapSDK.aspx
,其HTTP method為POST。
註記:Headers會顯示該物件的整個HTTP headers的訊息。
這意味著它是有夾帶些參數在request中,我們就能將目光專注在底下的Form Data。當中較為特別的是:
commandid:SearchStore
city:台北市
town:松山區
再來,我們就使用Postman來測試,並輸入以下條件。
HTTP method: POST
HTTP url : http://emap.pcsc.com.tw/EMapSDK.aspx
Body中選擇x-www-form-urlencoded
headers
body
輸入完成後,按下Send鍵。就能看到我們所要的店家資料呈現在底下的XML格式的資料中。
回歸原點來思考,我們要取得的店家資料是全台灣7–11的店家資料,並從剛剛的步驟發現,當我們輸入:
commandid:SearchStore
city:台北市
town:松山區
就能取得台北市松山區所有的7–11資料。同理,假設我們若將city輸入台北市、town輸入信義區,也是能得到台北市信義區所有的7–11的資料。
於是我們第一個動作就是要先去擷取各縣市的「區域」資料。
首先,先回到點選區域的頁面,並用開發者工具去觀看其API的運行。
結果發現有出現我們所想要的區域資料。接著我們在看看它的Headers情況為何:
可以看到Request URL也是為「http://emap.pcsc.com.tw/EMapSDK.aspx」
,其HTTP method為POST。於是,直接將目光轉移到底下的Form Data,可發現較為特別的參數為:
commandid: GetTown
cityid: 01
在將參數丟到Postman來進行測試,並輸入以下條件:
HTTP method: POST
HTTP url : http://emap.pcsc.com.tw/EMapSDK.aspx
Body中選擇x-www-form-urlencoded
headers
body
待輸入完成後,按下Send鍵。就能發現,其結果就是我們所想要看到的區域資料:
問題來了,由於cityid的值為數字。從結果來看,目前只知道01是「台北」。但02之後呢?每個cityid所對應到的地方是哪邊?
所以,我們必須去觀察這部份它所定義的規則為何。
可以透過source裡面去查詢有沒有相關的資源。這時,可以發現有個檔案叫做「emap.aspx」,底下也剛好有一個cityid的參數。
該參數是透過一個叫「AREACODE」所引入,在更進一步的追尋。發現它是源自於「lib/areacode.js」的檔案,於是我們就點開lib的資料夾,並選擇該檔案。
之後,我們就能看到各縣市的「區域」資料,其編號所對應到的地區就呈現在上面。
由於資料量並不多,筆者決定將這些資料用手動的方式將其city跟cityid轉成JSON格式的檔案。(可參考:JSON檔案)
最後,歸納一下我們要進行crawler的流程:
.
├── app.js
├── bin
│ └── www
├── controllers
│ └── get_controller.js
├── data
│ └── store_id.json
├── models
│ └── getdata_model.js
├── package.json
├── public
│ ├── images
│ ├── javascripts
│ └── stylesheets
│ └── style.css
├── routes
│ ├── index.js
│ └── users.js
├── views
├── error.ejs
├── index.ejs
└── success.ejs
├── .env
└── .gitignore
我們先將目標定為抓取台北市全部的區域試試看。至models
資料夾的``getdata_model.js
const request = require('request');
const cheerio = require('cheerio');
const areaData = require('../data/store_id.json');
const getTownName = (url, cityID) => {
return new Promise((resolve, reject) => {
let townNameArray = [];
request.post({
url: url,
form: {
commandid: "GetTown",
cityid: cityID
}
}, function (err, res, body) {
const $ = cheerio.load(body);
// 區域名稱
$('TownName').each(function (index, element) {
townNameArray.push($(this).text());
})
resolve(townNameArray);
})
})
}
getTownName("http://emap.pcsc.com.tw/EMapSDK.aspx", "01")
.then((result) => {
console.log(result); // 台北市全區資料
});
只要打印出result就是台北市全區的資料。
接著,就可以開始嘗試將各縣市的區域資料提取出來。這時,我們就會使用到剛剛所撰寫好的JSON檔案。
let areaDatas = [];
for (let i = 0; i < areaData.result.length; i += 1) {
const areaID = areaData.result[i].areaID;
const areaName = areaData.result[i].area;
const townName = await loadData.getTownName(url, areaID);
// 有些townName可能為0,因此進行篩選
if (townName.length !== 0) {
areaDatas.push({ cityName: areaName, townName: townName });
}
}
我們將areaDatas的結果寫成:
[ { cityName: '台北市',
townName:
[ '松山區',
'信義區',
'大安區',
'中山區',
'中正區',
'大同區',
'萬華區',
'文山區',
'南港區',
'內湖區',
'士林區',
'北投區' ] },
...
]
不難發現,這就是7–11提取各區店家所需的參數,只是筆者依據縣市來劃分縣市內各區的資料。
commandid:SearchStore
city:台北市
town:松山區
我們先將目標定為抓取台北市松山區全部的7–11試試看。
const request = require('request');
const cheerio = require('cheerio');
const getStoreData = (url, cityName, townName) => {
let storeArray = [];
let storeID = [];
let storeName = [];
let storeTele = [];
let storeFax = [];
let storeAddress = [];
let storeValues = "";
return new Promise((resolve, reject) => {
request.post({
url: url,
form: {
commandid: "SearchStore",
city: cityName,
town: townName
}
}, function (err, res, body) {
const $ = cheerio.load(body);
// 店家ID
$('POIID').each(function (index, element) {
//去空白
storeID.push($(this).text().replace(/\s/g, ''));
storeValues = index; // 該區所有店家的個數
})
// 店家名稱
$('POIName').each(function (index, element) {
storeName.push($(this).text());
})
// 店家電話
$('Telno').each(function (index, element) {
//去空白
storeTele.push($(this).text().replace(/\s/g, ''));
})
// 店家傳真
$('FaxNo').each(function (index, element) {
//去空白
storeFax.push($(this).text().replace(/\s/g, ''));
})
// 店家地址
$('Address').each(function (index, element) {
storeAddress.push($(this).text());
})
for (let i = 0; i <= storeValues; i += 1) {
storeArray.push({
storeCity: cityName,
storeTown: townName,
storeID: storeID[i],
storeName: storeName[i],
storeTele: storeTele[i],
storeFax: storeFax[i],
storeAddress: storeAddress[i]
});
}
resolve(storeArray);
})
})
}
getStoreData("http://emap.pcsc.com.tw/EMapSDK.aspx",
"台北市",
"松山區")
.then((result) => {
console.log(result); // 台北市松山區全部7-11的資料
};
只要打印出result就是台北市松山區全部7–11的資料了。
但我們的目標是台灣全區的7–11的資料。這時就要使用到上個步驟,最後處理出來areaDatas的結果:
// 各縣市
for (let i = 0; i < areaDatas.length; i += 1) {
// 縣市內各區域
for (let j = 0; j < areaDatas[i].townName.length; j += 1) {
let city = areaDatas[i].cityName;
let town = areaDatas[i].townName[j];
const storeDatas = await loadData.getStoreData(url,
city, town);
if (storeDatas[0].storeID !== undefined) {
totalStoreDatas.push({
city: city,
town: town,
storeDatas: storeDatas
});
}
}
}
最後,我們的目標結果就存放在totalStoreDatas中。
{
"result": [
{
"city": "台北市",
"town": "松山區",
"storeDatas": [
{
"storeCity": "台北市",
"storeTown": "松山區",
"storeID": "170945",
"storeName": "上弘",
"storeTele": "(02)25472928",
"storeFax": "(02)25459434",
"storeAddress": "台北市松山區敦化北路168號B2"
},
....
這部份完整的處理流程可從這裡看。
筆者在這個例子中,除了使用async/await來控制function的執行順序外,也嘗試使用了Promise all來操控同時發送多個request(這部份可在上述完整的處理流程檔案中看到)。其結果待測試後是有比起同時發送一個request結果還要來的快。
但也不太建議為了速度,同時間發送太多的request給server。這是因為:
要如何在抓取資料的時間及發送request的數量上取得一個平衡,這點筆者也還在學習怎麼拿捏中。
最後,在提及一下寫爬蟲的四個步驟:
寫crawler對筆者說非常的有趣,與筆者就讀研究所時的研究主題有關,屬於資料分析的一環。當然,要做資料分析前就得要有資料才行(笑)。
關於完整的code可以參考: